SPB Git

spb/rareindex

Public
16.3 KB · 318 lines tsx
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { Suspense } from 'react';5import { PageHeader } from '@/components/ui/page-header';6import { Card, CardHeader, Delta, EmptyState, Skeleton, Stat, Badge, Unavailable } from '@/components/ui/primitives';7import { MarketTable } from '@/components/market/market-table';8import { AssetBrowser } from '@/components/market/asset-browser';9import { Thumb } from '@/components/market/bits';10import type { SP } from '@/lib/search-params';11import { AssetTable } from '@/components/market/asset-list';12import { SalesTable, ListingsTable, LotsTable } from '@/components/market/sales-table';13import { LineChart } from '@/components/charts/line-chart';14import { BarChart } from '@/components/charts/bar-chart';15import { getBrandsInCategory, getCategoryRow, getCategorySeries, getMarketRows, getPopulationTrend, getSetsInCategory } from '@/lib/queries/markets';16import { rankedAssets, categoryScope } from '@/lib/queries/assets';17import { getRecentSalesInScope, getTopSales, listListings, listLots, listNews, listRadar } from '@/lib/queries/market-lists';18import { getIndex } from '@/lib/queries/indices';19import { fmtMoney, fmtNum, fmtRelative, fmtDate } from '@/lib/format';20import { categoryCrumbs, getCategory, humanize } from '@/lib/taxonomy';21import { CATEGORIES } from '@rareindex/taxonomy';2223export const revalidate = 300;2425export async function generateStaticParams() {26  return CATEGORIES.filter((c) => c.phase === 1).map((c) => ({ slug: c.slug }));27}2829export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {30  const { slug } = await params;31  const c = getCategory(slug);32  if (!c) return { title: 'Market not found' };33  return { title: `${c.name} market`, description: `${c.name} collectibles market on RareIndex: index, sales, volume, top movers, most valuable assets, listings and news.` };34}3536export default async function MarketPage({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise<SP> }) {37  const { slug } = await params;38  const sp = await searchParams;39  const row = await getCategoryRow(slug);40  if (!row) notFound();41  const { node, snapshot, counts } = row;42  const index = node.index ? await getIndex(node.index) : null;43  const subcategories = CATEGORIES.filter((c) => c.parent === slug);44  return (45    <div>46      <PageHeader47        crumbs={[{ label: 'Markets', href: '/markets' }, ...categoryCrumbs(slug)]}48        title={node.name}49        description={node.description ?? `${node.name} market data: verified sales, live listings, valuations and category index.`}50        meta={51          <>52            <Badge tone={node.phase === 1 ? 'index' : 'neutral'}>Phase {node.phase}</Badge>53            {index ? (54              <Link href={`/rareindex/${index.ticker}`} className="hover:text-fg">55                Subindex {index.ticker}56              </Link>57            ) : null}58            {node.compliance?.length ? <Badge tone="alert">{node.compliance.map(humanize).join(' · ')}</Badge> : null}59            <Link href={`/explore?category=${slug}`} className="hover:text-fg">60              Explore assets →61            </Link>62          </>63        }64      />6566      <section className="grid grid-cols-2 gap-px overflow-hidden rounded-md border border-border bg-border sm:grid-cols-4 lg:grid-cols-8">67        {[68          ['Tracked assets', fmtNum(counts.assets)],69          ['With valuation', fmtNum(counts.priced)],70          ['Sales (all time)', fmtNum(counts.sales)],71          ['Sales 30D', fmtNum(counts.sales30d)],72          ['Volume 30D', counts.volume30dUsd != null ? fmtMoney(counts.volume30dUsd, 'USD', { compact: true }) : '—'],73          ['Active listings', fmtNum(counts.listings)],74          ['Median RIV', counts.medianRivUsd != null ? fmtMoney(counts.medianRivUsd) : '—'],75          ['Market cap est.', snapshot?.marketCapEstUsd != null ? fmtMoney(snapshot.marketCapEstUsd, 'USD', { compact: true }) : '—'],76        ].map(([k, v]) => (77          <div key={k} className="bg-elevated px-3 py-2.5">78            <Stat label={k} value={v} />79          </div>80        ))}81      </section>82      {snapshot?.marketCapEstUsd != null ? <p className="mt-1 text-[11px] text-subtle">Market cap estimate = estimated population × representative RIV per asset, summed; indicative only (§124).</p> : null}83      {counts.assets > 0 && counts.priced === 0 ? (84        <p className="mt-2 rounded-md border border-border bg-sunken px-3 py-2 text-[12px] text-muted">85          <span className="font-medium text-fg">{fmtNum(counts.assets)} {node.name} assets are catalogued.</span> Valuations publish per asset once enough verified sales exist; guide prices from price-guide sources are shown in the meantime, labelled as such (never presented as market value).86        </p>87      ) : null}8889      <div className="mt-4">90        <Suspense fallback={<Skeleton className="h-[32rem]" />}>91          <AssetBrowser scope={{ category: slug }} sp={sp} basePath={`/markets/${slug}`} title={`Assets in ${node.name}`} subcategories={subcategories} />92        </Suspense>93      </div>9495      {counts.sales > 0 || counts.listings > 0 ? (96        <>97          <Suspense fallback={<Skeleton className="mt-4 h-72" />}>98            <IndexPanel slug={slug} />99          </Suspense>100101          <Suspense fallback={<Skeleton className="mt-4 h-72" />}>102            <Movers slug={slug} />103          </Suspense>104105          <Suspense fallback={<Skeleton className="mt-4 h-72" />}>106            <Activity slug={slug} />107          </Suspense>108        </>109      ) : (110        <Card className="mt-4 p-4">111          <p className="t-subtitle text-fg">Market analytics publish once verified sales or live listings exist in {node.name}.</p>112          <p className="mt-1 text-[13px] leading-relaxed text-muted">113            Movers, volume, records, value opportunities, auctions and the category index all derive from observed transactions. Until then the catalogue above is complete and guide prices are shown where a price-guide source reports them (labelled, never presented as market value).{' '}114            <Link href="/data" className="underline-offset-2 hover:text-fg hover:underline">115              How coverage grows →116            </Link>117          </p>118        </Card>119      )}120121      <Suspense fallback={<Skeleton className="mt-4 h-40" />}>122        <Structure slug={slug} />123      </Suspense>124    </div>125  );126}127128async function IndexPanel({ slug }: { slug: string }) {129  const series = await getCategorySeries(slug, 730);130  const points = series.filter((p) => p.indexValue != null).map((p) => ({ x: p.date, y: p.indexValue! }));131  const salesSeries = series.map((p) => ({ x: p.date, y: p.sales }));132  return (133    <section className="mt-4 grid gap-4 lg:grid-cols-[2fr_1fr]">134      <Card className="p-4">135        <h2 className="text-sm font-semibold">Category index</h2>136        <p className="text-xs text-muted">Daily category index from valuations of priced constituents; publishes once enough assets are priced.</p>137        <div className="mt-2">138          <LineChart ariaLabel={`${slug} category index`} height={220} series={[{ id: 'idx', label: 'Category index', kind: 'area', points }]} emptyLabel="Index building — not enough priced constituents yet" />139        </div>140      </Card>141      <Card className="p-4">142        <h2 className="text-sm font-semibold">Daily sales</h2>143        <p className="text-xs text-muted">Verified transactions per day (count)</p>144        <div className="mt-2">145          <LineChart ariaLabel={`${slug} daily sales`} height={220} series={[{ id: 'sales', label: 'Sales', kind: 'step', points: salesSeries }]} emptyLabel="No snapshot history yet" />146        </div>147      </Card>148    </section>149  );150}151152async function Movers({ slug }: { slug: string }) {153  const scope = categoryScope(slug);154  const [gainers, losers, volume, expensive, trending] = await Promise.all([155    rankedAssets('gainers', { scope, limit: 8, window: '7d' }),156    rankedAssets('losers', { scope, limit: 8, window: '7d' }),157    rankedAssets('volume', { scope, limit: 8 }),158    rankedAssets('expensive', { scope, limit: 8 }),159    rankedAssets('trending', { scope, limit: 8 }),160  ]);161  const block = (title: string, sub: string, items: typeof gainers, columns: Parameters<typeof AssetTable>[0]['columns']) => (162    <Card>163      <CardHeader title={title} subtitle={sub} />164      <AssetTable items={items} columns={columns} emptyTitle="No data yet" emptyDescription="Needs priced assets with sales history in this category." />165    </Card>166  );167  return (168    <section className="mt-4 grid gap-4 xl:grid-cols-2">169      {block('Top gainers · 7D', 'Assets with ≥3 sales used in valuation', gainers, ['riv', 'change7d', 'change30d', 'sales'])}170      {block('Top losers · 7D', 'Assets with ≥3 sales used in valuation', losers, ['riv', 'change7d', 'change30d', 'sales'])}171      {block('Highest volume · 30D', 'Sum of verified sale prices, USD', volume, ['riv', 'sales30d', 'change30d', 'liquidity'])}172      {block('Most valuable', 'Ranked by RareIndex Valuation', expensive, ['riv', 'latestSale', 'change1y', 'rarity'])}173      {block('Trending', 'Price × volume × listing momentum', trending, ['riv', 'trending', 'change7d', 'sales30d'])}174      <Card>175        <CardHeader title="Value opportunities" subtitle="Assets whose lowest ask sits materially below RIV — analytical data, not advice" />176        <AssetTable items={await rankedAssets('opportunity', { scope, limit: 8 })} columns={['riv', 'minAsk', 'opportunity', 'listings']} emptyTitle="No opportunities detected" emptyDescription="Requires both a valuation and active listings." />177      </Card>178    </section>179  );180}181182async function Activity({ slug }: { slug: string }) {183  const scope = categoryScope(slug);184  const [records, recent, listings, lots, news, radar, pop] = await Promise.all([185    getTopSales(6, scope),186    getRecentSalesInScope(scope, 10),187    listListings({ category: slug, pageSize: 10, sort: 'newest' }),188    listLots({ category: slug, limit: 6, sort: 'value' }),189    listNews({ category: slug, limit: 6 }),190    listRadar({ scope, limit: 6 }),191    getPopulationTrend(slug),192  ]);193  const popByGrader = new Map<string, Array<{ x: string; y: number }>>();194  for (const p of pop) (popByGrader.get(p.grader) ?? popByGrader.set(p.grader, []).get(p.grader)!).push({ x: p.reportDate, y: p.total });195  return (196    <section className="mt-4 grid gap-4 xl:grid-cols-2">197      <Card>198        <CardHeader title="Newest records" subtitle="Highest verified sales in this market" action={<Link href={`/records`} className="text-muted hover:text-fg">All records →</Link>} />199        <SalesTable items={records} emptyDescription="No verified sales in this category yet." />200      </Card>201      <Card>202        <CardHeader title="Latest sales" action={<Link href={`/sales?category=${slug}`} className="text-muted hover:text-fg">All sales →</Link>} />203        <SalesTable items={recent} emptyDescription="No sales in this category yet." />204      </Card>205      <Card className="xl:col-span-2">206        <CardHeader title="Live listings" subtitle="Asking prices are not market values" action={<Link href={`/listings?category=${slug}`} className="text-muted hover:text-fg">All listings →</Link>} />207        <ListingsTable items={listings.items} />208      </Card>209      <Card>210        <CardHeader title="Biggest auctions" subtitle="Lots by hammer, bid or high estimate" action={<Link href="/auctions" className="text-muted hover:text-fg">Auctions →</Link>} />211        <LotsTable items={lots} />212      </Card>213      <Card>214        <CardHeader title="Population trends" subtitle="Total graded population reported per grader" />215        {popByGrader.size ? (216          <div className="p-4">217            <LineChart ariaLabel="Population trend" height={200} series={[...popByGrader.entries()].map(([g, pts]) => ({ id: g, label: g.toUpperCase(), points: pts }))} />218          </div>219        ) : (220          <EmptyState title="No population reports" description="Population data appears when grading-company connectors publish reports for assets in this category." className="py-8" />221        )}222      </Card>223      <Card>224        <CardHeader title="Rare Radar" action={<Link href="/radar" className="text-muted hover:text-fg">Open radar →</Link>} />225        {radar.length ? (226          <ul className="divide-y divide-border text-[12px]">227            {radar.map((r) => (228              <li key={r.id} className="flex items-center justify-between gap-3 px-4 py-2">229                <Link href={`/asset/${r.assetSlug}`} className="truncate font-medium text-fg hover:underline">230                  {r.assetTitle}231                </Link>232                <span className="shrink-0 text-muted">233                  {humanize(r.kind)} · {fmtRelative(r.detectedAt)}234                </span>235              </li>236            ))}237          </ul>238        ) : (239          <EmptyState title="Radar is quiet" className="py-8" />240        )}241      </Card>242      <Card>243        <CardHeader title="News" action={<Link href={`/news?category=${slug}`} className="text-muted hover:text-fg">All news →</Link>} />244        {news.items.length ? (245          <ul className="divide-y divide-border text-[12px]">246            {news.items.map((n) => (247              <li key={n.id} className="px-4 py-2">248                <a href={n.url} target="_blank" rel="noopener nofollow" className="font-medium text-fg hover:underline">249                  {n.title}250                </a>251                <span className="block text-[11px] text-muted">252                  {n.sourceName} · {n.publishedAt ? fmtDate(n.publishedAt) : 'undated'}253                </span>254              </li>255            ))}256          </ul>257        ) : (258          <EmptyState title="No news indexed" description="News connectors are not yet publishing for this market." className="py-8" />259        )}260      </Card>261    </section>262  );263}264265async function Structure({ slug }: { slug: string }) {266  const [children, sets, brands] = await Promise.all([getMarketRows(slug), getSetsInCategory(slug, 40), getBrandsInCategory(slug, 30)]);267  return (268    <section className="mt-4 grid gap-4">269      {children.length ? (270        <Card>271          <CardHeader title="Subcategories" />272          <MarketTable rows={children} showPhase />273        </Card>274      ) : null}275      <div className="grid gap-4 lg:grid-cols-2">276        <Card>277          <CardHeader title="Sets & releases" subtitle={`${fmtNum(sets.length)}${sets.length >= 40 ? '+' : ''} releases catalogued · ranked by sales, then size`} action={<Link href={`/markets/${slug}?sort=number#assets`} className="text-muted hover:text-fg">Browse by set →</Link>} />278          {sets.length ? (279            <ul className="grid grid-cols-1 text-[12px] sm:grid-cols-2">280              {sets.map((s) => (281                <li key={s.slug} className="flex items-center gap-2 border-b border-border px-4 py-1.5">282                  <Thumb src={s.thumb} alt="" size={28} />283                  <Link href={`/set/${s.slug}`} className="min-w-0 flex-1 truncate font-medium text-fg hover:underline">284                    {s.name}285                    {s.code ? <span className="ml-1 text-subtle">{s.code}</span> : null}286                    {s.releaseYear ? <span className="ml-1 text-subtle">{s.releaseYear}</span> : null}287                  </Link>288                  <span className="num shrink-0 text-muted">289                    {fmtNum(s.assets)}{s.sales ? ` · ${fmtNum(s.sales)} sales` : ''}{s.priced ? ` · ${fmtNum(s.priced)} priced` : ''}290                  </span>291                </li>292              ))}293            </ul>294          ) : (295            <EmptyState title="No sets catalogued" className="py-8" />296          )}297        </Card>298        <Card>299          <CardHeader title="Brands" subtitle="Median RIV of priced assets" />300          {brands.length ? (301            <div className="p-4">302              <BarChart ariaLabel="Assets per brand" data={brands.slice(0, 12).map((b) => ({ label: b.brand, value: b.assets, sublabel: `${b.priced} priced · median RIV ${b.medianRivUsd != null ? fmtMoney(b.medianRivUsd) : '—'}`, href: `/brand/${encodeURIComponent(b.brand)}` }))} />303            </div>304          ) : (305            <EmptyState title="No brand data" className="py-8" />306          )}307        </Card>308      </div>309      <p className="text-[11px] text-subtle">310        Unavailable metrics render as <Unavailable /> rather than estimates (§192).311      </p>312      <span className="hidden">313        <Delta value={null} />314      </span>315    </section>316  );317}318
Files · 928c8dfd3710b817fe8e1e510fc295f59b7ddb9b
Menu

Theme